package org.unidata.mdm.meta.service.impl.measureunits;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import org.unidata.mdm.core.context.ModelChangeContext;
import org.unidata.mdm.core.context.ModelGetContext;
import org.unidata.mdm.core.context.ModelRefreshContext;
import org.unidata.mdm.core.context.ModelRemoveContext;
import org.unidata.mdm.core.dto.ModelGetResult;
import org.unidata.mdm.core.service.CustomPropertiesSupport;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.service.impl.AbstractModelComponent;
import org.unidata.mdm.core.type.model.ModelDescriptor;
import org.unidata.mdm.core.type.model.ModelImplementation;
import org.unidata.mdm.core.type.model.ModelSource;
import org.unidata.mdm.core.util.SecurityUtils;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.context.GetMeasurementUnitsContext;
import org.unidata.mdm.meta.context.SourceMeasurementUnitsContext;
import org.unidata.mdm.meta.context.UpsertMeasurementUnitsContext;
import org.unidata.mdm.meta.dao.MeasurementUnitsDAO;
import org.unidata.mdm.meta.dto.GetMeasurementCategoryResult;
import org.unidata.mdm.meta.dto.GetMeasurementUnitsResult;
import org.unidata.mdm.meta.exception.MetaExceptionIds;
import org.unidata.mdm.meta.exception.ModelRuntimeException;
import org.unidata.mdm.meta.exception.ModelValidationException;
import org.unidata.mdm.meta.po.MeasurementUnitsPO;
import org.unidata.mdm.meta.serialization.MetaSerializer;
import org.unidata.mdm.meta.service.impl.measureunits.instance.MeasurementUnitsInstanceImpl;
import org.unidata.mdm.meta.service.impl.measureunits.instance.MeasurementUnitsInstanceImpl.MeasurementCategoryImpl;
import org.unidata.mdm.meta.type.instance.MeasurementUnitsInstance;
import org.unidata.mdm.meta.type.model.measurement.MeasurementCategory;
import org.unidata.mdm.meta.type.model.measurement.MeasurementUnit;
import org.unidata.mdm.meta.type.model.measurement.MeasurementUnitsModel;
import org.unidata.mdm.system.exception.PlatformBusinessException;
import org.unidata.mdm.system.exception.ValidationResult;

/**
 * @author Mikhail Mikhailov on Nov 5, 2020
 */
@Component
public class MeasurementUnitsComponent extends AbstractModelComponent implements ModelImplementation<MeasurementUnitsInstance>, CustomPropertiesSupport {
    /**
     * Id regexp
     */
    private static final Pattern ID_PATTERN = Pattern.compile("[\\w]*");
    /**
     * conversion function.
     */
    private static final String BASE_CONVERSION = "value";
    /**
     * The Constant MEASUREMENT_CATEGORY_NAME_TOO_LONG.
     */
    private static final String MEASUREMENT_CATEGORY_NAME_TOO_LONG = "app.meta.measurement.category.name.too.long";
    /**
     * The Constant MEASUREMENT_CATEGORY_NAME_EMPTY.
     */
    private static final String MEASUREMENT_CATEGORY_NAME_EMPTY = "app.meta.measurement.category.name.empty";
    /**
     * Category name invalid (contains invalid characters).
     */
    private static final String MEASUREMENT_CATEGORY_NAME_INVALID = "app.meta.measurement.category.name.invalid";
    /**
     * The Constant MEASUREMENT_UNIT_NAME_TOO_LONG.
     */
    private static final String MEASUREMENT_UNIT_NAME_TOO_LONG = "app.meta.measurement.unit.name.too.long";
    /**
     * The Constant MEASUREMENT_UNIT_NAME_EMPTY.
     */
    private static final String MEASUREMENT_UNIT_NAME_EMPTY = "app.meta.measurement.uint.name.empty";
    /**
     * The Constant MEASUREMENT_UNIT_DUPLICATES.
     */
    private static final String MEASUREMENT_UNIT_DUPLICATES = "app.meta.measurement.unit.duplicates";
    /**
     * Unit name invalid (contains invalid characters).
     */
    private static final String MEASUREMENT_UNIT_NAME_INVALID = "app.meta.measurement.unit.name.invalid";
    /**
     * Unit fn instantiation.
     */
    private static final String MEASUREMENT_UNIT_CONVERSION_INSTANTIATION = "app.meta.measurement.unit.conversion.instantiation";
    /**
     * The Constant INCORRECT_NUMBER_OF_BASE_UNITS.
     */
    private static final String INCORRECT_NUMBER_OF_BASE_UNITS = "app.meta.incorrect.number.of.base.units";
    /**
     * MMS.
     */
    private MetaModelService metaModelService;
    /**
     * MU DAO.
     */
    @Autowired
    private MeasurementUnitsDAO measurementUnitsDAO;
    /**
     * MUI. Measurement units are singleton chache per storage id.
     */
    private final Map<String, MeasurementUnitsInstance> instances = new ConcurrentHashMap<>(4);
    /**
     * Constructor.
     */
    public MeasurementUnitsComponent(MetaModelService metaModelService) {
        super();
        this.metaModelService = metaModelService;
        metaModelService.register(this);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public ModelDescriptor<MeasurementUnitsInstance> descriptor() {
        return Descriptors.MEASUREMENT_UNITS;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public MeasurementUnitsInstance instance(String storageId, String id) {
        return instances.computeIfAbsent(storageId, this::load);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public ModelGetResult get(ModelGetContext get) {

        GetMeasurementUnitsContext ctx = (GetMeasurementUnitsContext) get;
        GetMeasurementUnitsResult result = new GetMeasurementUnitsResult();

        MeasurementUnitsInstance i = instance(SecurityUtils.getStorageId(ctx), null);
        processMeasurementUnits(i, ctx, result);
        processInfoFields(i, ctx, result);

        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public MeasurementUnitsModel assemble(ModelChangeContext ctx) {

        UpsertMeasurementUnitsContext change = (UpsertMeasurementUnitsContext) ctx;

        MeasurementUnitsInstance current = instance(SecurityUtils.getStorageId(change), null);
        MeasurementUnitsModel target = new MeasurementUnitsModel();
        if (change.getUpsertType() == ModelChangeContext.ModelChangeType.FULL) {
            target.withValues(change.getMeasurementCategoriesUpdate());
        } else {

            Map<String, MeasurementCategory> merge = current.toSource()
                    .getValues()
                    .stream()
                    .collect(Collectors.toMap(MeasurementCategory::getName, Function.identity()));

            if (CollectionUtils.isNotEmpty(change.getMeasurementCategoriesUpdate())) {
                merge.putAll(
                    change.getMeasurementCategoriesUpdate().stream()
                        .collect(Collectors.toMap(MeasurementCategory::getName, Function.identity())));
            }

            if (CollectionUtils.isNotEmpty(change.getMeasurementCategoriesDelete())) {
                change.getMeasurementCategoriesDelete().forEach(merge::remove);
            }

            target.withValues(merge.values());
        }

        target
            .withVersion(current.getVersion() + 1)
            .withCreateDate(OffsetDateTime.now())
            .withCreatedBy(SecurityUtils.getCurrentUserName());

        processInfoFields(target, change);

        return target;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void upsert(ModelChangeContext ctx) {

        UpsertMeasurementUnitsContext change = (UpsertMeasurementUnitsContext) ctx;
        MeasurementUnitsModel target = assemble(change);

        List<ValidationResult> validations = new ArrayList<>();
        validations.addAll(validate(target));
        validations.addAll(metaModelService.allow(SourceMeasurementUnitsContext.builder()
                    .source(target)
                    .build()));

        if (CollectionUtils.isNotEmpty(validations)) {
            throw new ModelValidationException("Cannot upsert measurement units. Validation errors exist.",
                    MetaExceptionIds.EX_META_UPSERT_MEASUREMENT_UNITS_VALIDATION, validations);
        }

        put(SecurityUtils.getStorageId(change), target);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void refresh(ModelRefreshContext refresh) {
        instances.computeIfPresent(SecurityUtils.getStorageId(refresh), (k, v) -> {
            v.beforeRemove();
            return load(k);
        });
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void remove(ModelRemoveContext remove) {
        // Not supported
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public Collection<ValidationResult> validate(ModelSource model) {

        MeasurementUnitsModel source = (MeasurementUnitsModel) model;
        List<ValidationResult> errors = new ArrayList<>();
        for (MeasurementCategory category : source.getValues()) {

            // 1. Name
            if (StringUtils.isBlank(category.getName())) {
                String message = "Measurement units category's name is blank. Category name must not be blank.";
                errors.add(new ValidationResult(message, MEASUREMENT_CATEGORY_NAME_EMPTY));
            } else {

                // 2. Shape
                if (!ID_PATTERN.matcher(category.getName()).matches()) {
                    String message = "Measurement units category name [{}] is invalid. The name contains invalid characters.";
                    errors.add(new ValidationResult(message, MEASUREMENT_CATEGORY_NAME_INVALID, category.getName()));
                }

                // 3. Name length
                if(category.getName().length() > 255) {
                    String message = "Measurement units category name too long. Category [{}], max length [{}]";
                    errors.add(new ValidationResult(message, MEASUREMENT_CATEGORY_NAME_TOO_LONG, category.getName(), 255));
                }
            }

            // 4. Values
            errors.addAll(checkValues(category));

            // 5. Custom properties
            errors.addAll(validateCustomProperties(category.getName(), category.getCustomProperties()));
        }

        // 6. Finally try to instantiate the model to provoke conversion functions failures
        if (CollectionUtils.isEmpty(errors)) {
            try {
                MeasurementUnitsInstance test = new MeasurementUnitsInstanceImpl(source);
                test.beforeRemove();
            } catch (PlatformBusinessException e) {
                errors.add(new ValidationResult("Model instantiation failed [{}].", MEASUREMENT_UNIT_CONVERSION_INSTANTIATION, e.getMessage()));
            }
        }

        return errors;
    }

    /**
     * {@inheritDoc}
     */
    public void put(String storageId, MeasurementUnitsModel source) {

        Objects.requireNonNull(storageId, "Storage id must not be null.");

        MeasurementUnitsPO po = new MeasurementUnitsPO();
        po.setCreatedBy(SecurityUtils.getCurrentUserName());
        po.setStorageId(storageId);
        po.setRevision(source.getVersion());
        po.setContent(MetaSerializer.measurementUnitsToCompressedXml(source));

        try {
            measurementUnitsDAO.save(po);
        } catch (DuplicateKeyException dke) {
            throw new ModelRuntimeException(
                    "Cannot save measurement units. Revisions conflict [expected next {}].",
                    dke,
                    MetaExceptionIds.EX_META_UPSERT_MEASUREMENT_UNITS_REVISION_EXISTS,
                    po.getRevision());
        }
    }

    private Collection<ValidationResult> checkValues(MeasurementCategory category) {

        List<ValidationResult> errors = new ArrayList<>();
        Map<String, Integer> cardinality = new HashMap<>();
        Set<String> base = new HashSet<>();
        for (MeasurementUnit unit : category.getUnits()) {
            checkValue(unit, category, errors, cardinality, base);
        }

        Set<String> duplicates = cardinality.entrySet().stream()
                .filter(entry -> entry.getValue() > 1)
                .map(Entry::getKey)
                .collect(Collectors.toSet());

        if (CollectionUtils.isNotEmpty(duplicates)) {
            String message = "Duplicate unit name(s) [{}]";
            errors.add(new ValidationResult(message, MEASUREMENT_UNIT_DUPLICATES, duplicates.stream().collect(Collectors.joining(", "))));
        }

        if (base.size() != 1) {
            String message = "Base measurement unit must be defined exactly once for a category. Found [{}] base units. Names: [{}]";
            errors.add(new ValidationResult(message, INCORRECT_NUMBER_OF_BASE_UNITS, base.size(), base.toString()));
        }

        return errors;
    }

    private void checkValue(
            MeasurementUnit unit,
            MeasurementCategory category,
            List<ValidationResult> errors,
            Map<String, Integer> cardinality,
            Set<String> base) {

        if (StringUtils.isBlank(unit.getName())) {
            String message = "Measurement unit's name in category [{}] is blank. Unit name must not be blank.";
            errors.add(new ValidationResult(message, MEASUREMENT_UNIT_NAME_EMPTY, category.getName()));
        } else {

            // 1. Shape
            if (!ID_PATTERN.matcher(unit.getName()).matches()) {
                String message = "Measurement unit name [{}] is invalid. The name contains invalid characters.";
                errors.add(new ValidationResult(message, MEASUREMENT_UNIT_NAME_INVALID, unit.getName()));
            }

            // 2. Length
            if(unit.getName().length() > 255) {
                String message = "Measurement unit's name too long. Unit name [{}], max length [{}]";
                errors.add(new ValidationResult(message, MEASUREMENT_UNIT_NAME_TOO_LONG, unit.getName(), 255));
            }

            cardinality.compute(unit.getName(), (k, v) -> v == null ? Integer.valueOf(1) : Integer.valueOf(v.intValue() + 1));
        }

        if (unit.isBase()) {

            base.add(unit.getName());

            String baseConversionFunction = unit.getConversionFunction();
            if (!BASE_CONVERSION.equals(baseConversionFunction)) {
                String message = "Measurement unit's base conversion function is incorrect. [{}] is excpected. Unit name [{}].";
                errors.add(new ValidationResult(message, MEASUREMENT_UNIT_NAME_TOO_LONG, BASE_CONVERSION, unit.getName()));
            }
        }
    }

    private MeasurementUnitsInstance load(String storageId) {

        MeasurementUnitsPO po = measurementUnitsDAO.current(storageId);
        if (Objects.isNull(po) || ArrayUtils.isEmpty(po.getContent())) {
            return new MeasurementUnitsInstanceImpl(new MeasurementUnitsModel()
                    .withVersion(0)
                    .withStorageId(storageId));
        }

        return new MeasurementUnitsInstanceImpl(MetaSerializer.measurementUnitsFromCompressedXml(po.getContent()));
    }

    private void processMeasurementUnits(MeasurementUnitsInstance i, GetMeasurementUnitsContext ctx, GetMeasurementUnitsResult dto) {

        if (!ctx.isAllCategories() && CollectionUtils.isEmpty(ctx.getCategoryIds())) {
            return;
        }

        List<GetMeasurementCategoryResult> categories;
        if (ctx.isAllCategories()) {
            categories = i.getCategories().stream()
                    .map(el -> ((MeasurementCategoryImpl) el).getSource())
                    .map(GetMeasurementCategoryResult::new)
                    .collect(Collectors.toList());
        } else {
            categories = ctx.getCategoryIds().stream()
                    .map(i::getCategory)
                    .filter(Objects::nonNull)
                    .map(el -> ((MeasurementCategoryImpl) el).getSource())
                    .map(GetMeasurementCategoryResult::new)
                    .collect(Collectors.toList());
        }

        dto.setMeasurementCategories(categories);
    }
}
